使用逻辑
在前面我们已经了解到:Keycloak = 一个“身份域 + 身份数据 + 认证协议引擎”的服务
它不存业务数据,只负责用户的身份和访问权限认证。
(一) 认证流程
回忆在 intro 里讲解的“认证流程”,具体步骤如下:
场景 1:用户注册
- 用户访问 应用软件
- 被重定向到 Keycloak 注册
- Keycloak 创建 User,并分配一个 唯一的 用户ID
sub,然后返回含sub的JWT - 应用软件 后端收到
JWT,用sub查询 应用数据库 - 发现没有 → 创建 Focus 用户
场景 2:用户登录
- 用户访问 应用软件
- 被重定向到 Keycloak
- **Keycloak 发现已经登录 → 不用注册 **→ 得到
access_token - 用得到的
access_token调用 Ordinis Learning 的后端API ,Learning 后端收到 JWT(就是access_token) - 应用软件 后端验证
access_token(签名/issuer/audience) - 应用软件 后端从
access_token里取sub(Keycloak userId), - 应用软件 后端用
sub查 业务数据库Learning DB,获取用户的业务信息,查/创建用户(首次登录自动建档)
由上面的流程可知:用户的信息其实在 Keycloak 数据库和 应用软件数据库 各存了一份,使用的 UID 分别是 sub 和user_id 。应用软件后端 在收到传过来的 JWT 里面的 sub 之后,其实不能马 上用 sub 去 查数据库,而是先要通过一张映射表(通常在 user 里)把 sub 映射成自己的 user_id(UUID),然后再用 自己的 user_id(UUID) 去查业务数据库。
Keycloak 发的 JWT(access token):
sub:用户唯一 ID(你用来做业务侧 user 绑定)preferred_username:可选用于展示/同步realm_access.roles/resource_access:角色(做粗粒度授权)exp:过期时间(后端校验)
注意:Keycloak 的 access_token 是直接返回给前端,前端再去调用 应用软件的后端API 。
Keycloak 只负责“发 token”,从不该调用你的后端 API,更不会转发业务响应。所有业务 API 调用,都是:前端 → 你的后端。
Browser
↓ 用户访问你的应用(前端)
https://focus.ordinis.app
前端发现 未登录 → 发起 OIDC 登录。
↓ 302 redirect,跳转到 Keycloak
https://auth.ordinis.dev/realms/ordinis/protocol/openid-connect/auth
做完认证
↓ 302 redirect 跳回前端
https://focus.ordinis.app/callback?code=AUTH_CODE
前端(或 BFF)向 Keycloak 的 token endpoint 请求:POST /protocol/openid-connect/token
得到:
- `access_token`(JWT)
- `id_token`
- `refresh_token`
↓ 前端调用你的后端 API
GET https://api.focus.ordinis.app/todos
Authorization: Bearer <access_token>
注意:OIDC 登录一定要“跳转到 Keycloak 页面”吗?
是的,用户会被重定向到 Keycloak 的登录页面。不推荐在前端自己做账号密码登录,再用 API 和 Keycloak 通信
这是因为OAuth / OIDC 的根本设计哲学:你的应用永远不应该直接接触用户密码
如果你“自己做登录页 + API 调 Keycloak”,这叫 ROPC(Resource Owner Password Credentials) 模式:
前端收集用户名 + 密码
↓
POST 给 Keycloak token endpoint
⚠️ **严重问题:**前端拿到明文密码(XSS 风险),也需要自己制作 MFA,社交登录等功能。
而且也不用担心 登录页面“风格不统一”,因为Keycloak 页面 的HTML / CSS / Logo 是可以自定义的
(二)Keycloak 的核心对象
我们接下来要以 Ordinis 为品牌做软件矩阵,旗下会包括多款软件比如:
Oridinis FocusOridinis LearningOridinis SMS
虽然属于同一个品牌,但是每个软件都应该要有自己的独立的一套用户系统,互相之间隔离。
1. Realm(域)
一个 Realm = 一套完全独立的用户系统。
Keycloak 中默认含有一个 Realm: master,但是这个 Realm 是给Keycloak系统管理用的,不要给真实用户用。
在 Keycloak 中,所有 Ordinis 旗下的软件都应该属于 同一个 Realm:Ordinis,这个 Realm 是新创建的。
2. Client(应用 / 服务)
Client = 一个要“使用 Keycloak 登录能力的应用”
比如我们接下来要开发的软件 Oridinis Focus,就是一个 Client
Client 决定:
- 用什么协议(OIDC / OAuth2)
- 怎么回调(redirect_uri)
- token 里带什么信息
- 是否是 public / confidential
3. User(用户)
注册成为 Ordinis Focus 用户 = 在 Keycloak 创建 User
例如:所有 Oridinis Focus 的用户都 同样注册为 Keycloak 的用户
但是这个用户信息不是通用于所有 Realm 的,每个软件自己的用户数据 = 各自独立的数据库
Keycloak 不是 MySQL,不是一个数据库, Keycloak 的每个用户和 具体软件的用户表是直接关联的,不需要像 MySQL 一样为每个 Realm 创建专有 “系统用户” 来读写数据,
Keycloak User 包含:
| 类型 | 举例 |
|---|---|
| 基础属性 | username / email / name |
| 凭据 | password / OTP / WebAuthn |
| 状态 | enabled / emailVerified |
| 元数据 | attributes(KV) |
注意:用户表的最小隔离单位是 Realm 而不是 Client,这意味这同一个软件矩阵下,用户在 Oridinis Focus 中注册了 Keycloak 身份,这个身份会保存到 Oridinis 这个 Realm 中,因此 Oridinis Learning 也可以访问到 这个用户。但是这不意味着 Oridinis Focus 的用户会自动成为了 Oridinis Learning 的用户。因为在应用软件后端一侧还有业务数据库,即如 Oridinis Learning DB 和 Oridinis Focus DB。
例如: Oridinis Focus 的用户在没有注册 Oridinis Learning 的情况下去尝试登录 Oridinis Learning ,虽然 Keycloak 能找到该用户并返回一个 sub,但是 Oridinis Learning DB 的映射表中不存在该 sub 和 user_id 的对应关系。所以会登录失败,或者直接创建新用户(这就是站内 SSO)
接着上面的问题思考:既然具体的用户登记是在 应用软件的业务数据库记录 的,这个 Client 还有什么用?直接使用一个 Realm 不就好了吗?为什么还要在 Realm 下创建那么多 Client?
这是因为,定制 token 的最小隔离单位是 Client 而不是 Realm。
例如 Access Token 里一定有一个字段:
"aud": "client_id"
使用这个 字段,可以清晰地显示 “这个用户属于哪个 Client ,也就是哪个具体应用软件"
同时,每个 Client 可以有不同的登录策略,比如是否允许注册,是否强制邮箱验证,是否支持社交登录,是否要求 MFA,登录后跳转到哪里,这些都是 在 Client 层面定制的
因此:你现在的直觉是:
既然用户登记在业务数据库,Client 好像没啥用
更准确的说法是:
Client 不负责“用户是否存在”, Client 负责“这个身份以什么方式、访问哪个应用、拿到什么 token”。
4. Role(角色 = 权限语义)
-
Realm Role(领域级角色):
即这个身份在这个 Realm 中是什么层级 / 身份,相当于维护这个 Realm 的 “员工” 而不是被记录进去的 “用户”。一般分为
user、admin、premium、staff -
Client Role(应用级角色)
某一个 Client 中记录的用户,也就是业务用户,只对某个 Client 有意义
非常适合做权限控制:Token 中通常嵌在:
resource_access: {
ordinis-api: {
roles: ["todo.read"]
}
}
思考:什么叫用户的权限?用户在具体应用软件的权限,比如是否是VIP,不是记录在 应用软件的业务数据库中的吗?为什么 Client 中要记录什么 read write权限?
Client Role里的“权限”(read / write),不是业务权限:是否 VIP、是否付费、是否解锁某个具体功能
Client Role 解决的是:“这个 token,能不能访问某一类 API”。它是一个安全边界声明,不是业务状态。
例如 Realm 层面 验证了这个用户确实已经注册过了(sub 存在), 但是 不知道这个身份有没有资格调用这个服务 / 接口族,例如能不能访问 /api/focus/*,能不能访问 /api/learning/*,是不是管理员接口
因此 Client 层面制定 的 Role 权限,就规定了这些。如果你不用 Client Role,所有 API 都接受任何登录用户,这是不安全也不方便的。
5. Group(用户集合)
Group 是 Role 的容器
- Group 可以绑定多个 Role
- User 加入 Group = 自动继承 Role
非常适合:
- 免费用户 / 付费用户
- 学生 / 教师
- 内测 / 正式
6. Protocol Mapper
决定 token 里“有什么”
例如:
- token 里有没有 email?
- role 是不是写进 JWT?
- custom attribute 要不要暴露?
你后端解析 JWT,全靠这里。
(三)创建 Client
接下来开始做实操流程:从创建 Realm 开始,后端用 Flask 搭建一个最小服务,目的是跑通 ”注册“ ”登录“ 这两个流程
现在 Keycloak 已经能通过 https://auth.ordinis.dev 正常访问,并且 Keycloak 在容器里监听 127.0.0.1:8081 -> 8080,Nginx/Cloudflare 做外部 HTTPS 入口。
我们先明确理想效果为:
- 访问
https://focus.ordinis.dev/ - 点“Login” → 跳转到
auth.ordinis.dev的 Keycloak 登录页 - 登录页里点 “Register” 完成注册(Keycloak 自带)
- 登录成功后回跳到
https://focus.ordinis.dev/callback - Flask 拿到 token,并显示当前登录用户信息(
sub / preferred_username / email等)
1. 创建 Realm
登录 Keycloak Admin Console(Master realm),左上角下拉 → Create realm → Realm name:Ordinis
Resource file = “用一个 JSON 文件一次性导入一个 Realm 的完整配置”,第一次创建空着就好

然后进入新 Realm:ordinis

2. 开启“用户自助注册”
Realm settings → Login → 开启 User registration
- (可选)开启: Email as username/Verify email / Forgot password 等(后面有了邮件服务器再加)
这样注册流程由 Keycloak 提供:用户在登录页会看到 “Register”。这是最小跑通注册的方式。

3. 创建 Client
进入 ordinis Realm → Clients,默认的内容如下:

这些 Clients 不是给你业务用的,是 Keycloak 自己的基础设施 Clients。因为 Keycloak 本身就是一个 OIDC / OAuth2 系统, 而 Keycloak 的控制台、账户中心、API、联邦功能,全都 是“Client”形式在使用 OIDC 的。相当于是“用 OIDC 管 OIDC”**
-
security-admin-console:👉 一个 Web Client,你现在看到的 Keycloak 管理后台本身 -
realm-management:👉 定义“谁可以管理这个 Realm” -
它提供的 Client Roles 包括:
create-realm,manage-users,manage-clients,view-realm,manage-events等等。当你给用户分配:Client: realm-management
Roles: manage-realm / create-realm / manage-users你本质上是在说:“这个用户可以管理整个 Realm”
-
account:👉 用户自助服务中心(改密码、看会话、看登录设备),在/realms/Ordinis/account/ -
account-console:👉 新版 Account UI 的前端 Client,本质上是account的 UI 版本 -
admin-cli:👉 命令行 / API 管理 Realm,比如:kcadm.sh get users -r ordinis就是通过这个 Client 发 Token 的。 -
broker:👉 Identity Broker(第三方登录),比如:Google,GitHub,学校 SSO,企业 IdP
点击 Create client
-
Client type:OpenID Connect(OIDC)
- OAuth2 解决“授权访问资源”(access token)
- OIDC 在 OAuth2 上再加一层身份协议,提供 ID Token(JWT),用来表达“用户是谁”
-
Client ID:
ordinis-focus-web-
这是 client 的“唯一标识符”,会出现在各种协议参数里,例如:
-
/protocol/openid-connect/auth?...&client_id=ordinis-focus-web
-
-
Name:
ordinis-focus-web- 一般可与 Client ID 一致,不参与协议核心校验
-
Always display in UI: On
- 含义:是否在某些 Keycloak UI(例如账号管理页面)里展示这个 client,可开可不开
-
Next

我们准备用子域名 focus.ordinis.dev 跑 Flask:
- Client authentication:ON(Confidential client)
- 含义:这个 client 是
Confidential Client,Keycloak 会生成Client Secret,并要求 client 在某些 token 交换环节 出示凭证(client secret 或私钥) 来证明“我是这个应用本人”。 - 由于需要安全保存这个
Client Secret,因此只有后端服务(Flask)建议开启这个模式。 - 如果是
OFF,则对应的是Public client,一般用于 不能安全保存 secret 的情景,如纯前端项目
- 含义:这个 client 是
-
Standard flow:ON(必须)
- 含义:Standard flow = 启用 Authorization Code Flow,就是启用我们之前讲的那一套 “跳转网页认证流程“ 上加上 用
code换token这个步骤,具体在 第四板块会解释。 - 前端跳到 Keycloak → 用户在 Keycloak 登录成功 → Keycloak 带着
code重定向回你的redirect_uri→ 应用用code去 Keycloak 换 token(以及验证) - 常见跳转网页:
/auth?...response_type=code&client_id=...&redirect_uri=...
- 含义:Standard flow = 启用 Authorization Code Flow,就是启用我们之前讲的那一套 “跳转网页认证流程“ 上加上 用
-
Direct access grants:OFF(建议先不要开)
- 含义:允许你的应用“直接拿用户名密码去 Keycloak 换 token”,俗称 password grant (ROPC)
- 这会导致你的应用收集用户密码,破坏 IdP 的边界
-
Implicit flow:OFF
- 含义:Implicit Flow 是 OAuth 2.0 在 早期为浏览器 SPA 设计的一种授权方式。Keycloak 直接把 token 放在 redirect URL 里返回,没有 authorization code,因此极度不安全。
-
Service accounts:OFF(此处不需要)
- 含义:让 client 以“自己”的身份拿 token
- 适用于机器对机器(M2M):例如一个后台任务调用另一个服务,不需要用户登录。开启后 Keycloak 会给这个 client 生成一个“服务账号用户”(service account user),client 用
client_id+secret直接换取 access token(没有用户参与)
-
OAuth 2.0 Device Authorization Grant:OFF
- 含义:这是 OAuth2 专门为**“没有键盘/浏览器的设备”**设计的登录方式。
- 流程是这样的:
- 设备请求 Keycloak:“我想登录,但我没法弹浏览器”
- Keycloak 返回:
user_code(给用户看的),verification_uri(让用户用手机/电脑访问) - 用户在 另一台设备 上完成登录
- 原设备轮询 Keycloak,拿到 token
-
OIDC CIBA Grant:OFF
- CIBA = 用户交互 ≠ 登录发起端,举个银行系统的例子:
- ATM → Keycloak:“我想让用户 Alice 登录”,但是不跳转浏览器,而是 让 Keycloak 给 Alice 的手机 App 发通知:“是否确认本次登录?”,Alice 在手机上点「确认」,Keycloak 通过后台通道把 token 发给原 client
-
Next

然后是 Login Setting:
-
Root URL:
https://focus.ordinis.dev- 给 Keycloak 用来拼接一些相对路径、展示链接、默认回跳等的“基础 URL”
-
Home URL:
https://focus.ordinis.dev- Keycloak 在某些 UI 或流程里用作“回到应用首页”的链接
-
Valid redirect URIs:
https://focus.ordinis.dev/*- 含 义:Keycloak 只允许把用户带着 code/token 跳回这些地址
-
Valid post logout redirect URIs:
https://focus.ordinis.dev/- 含义:用户在 Keycloak 或你的应用触发 logout 后,允许跳回的地址白名单
- 类似 redirect URI 的安全机制,只不过用于退出登录后的回跳
-
Web origins:
https://focus.ordinis.dev- 含义: CORS 的白名单(允许哪些前端 origin 直接用浏览器调用 Keycloak 的端点)
- 浏览器同源策略:
https://focus.ordinis.dev的 JS 去请求https://auth.ordinis.dev会触发跨域 - Keycloak 必须回
Access-Control-Allow-Origin: https://focus.ordinis.dev

然后点 Save 保存,保存后到:
Clients→ordinis-focus-web→ Credentials
-
Client Authenticator:Client Id and Secret
- client 在与 Keycloak 交换 token 时,用 client_id + client_secret 作为认证方式,属于 OAuth2 的客户端认证方式之一(另一类是私钥 JWT、mTLS 等)
-
Client Secret:即前面提到的 Keycloak 为 confidential client 生成的共享密钥
-
主要用于:code → token 的交换(token endpoint)
-
Regenerate 会让旧 secret 立刻失效(所有使用旧 secret 的服务会登录/换 token 失败)
-
-
Registration access token:用于 动态客户端注册(Dynamic Client Registration) 的管理 token
- 大多数项目不需要,用不到就忽略。

复制这个 Client Secret 后面有用。
(四) Client 架构进一步详解
我们在前面讲到了,Client 对应的是一个具体的应用服务。
但是我们之前讲的是 “一个应用如 Ordinis Focus 对应一个 Client” ,这其实是有失偏颇的。
我们必须要澄清的一个点:Keycloak 里的一个 Client,定义的是 Keycloak 和某个具体的对象之间的交互逻辑。
如果这个项目前后端分离,且前端和后端和 Keycloak 都有设计交互,那么本质上就要为 Keycloak 设计两套交互逻辑,分别对应前端和后端,因此要做两个 Client。如果前端和后端只有一方有做和 Keycloak 的交互,那么只用做一个 Client 即可。
架构 A:SPA(纯前端)模式
我们之前介绍的就是这种模式,具体流程如下:
1. 用户访问 focus.ordinis.dev
2. 浏览器被重定向到 Keycloak
3. 用户登录成功
4. Keycloak 直接把 access_token 返回给浏览器
(例如在 URL fragment 里)
5. 浏览器拿 access_token 调后端
6. 后端验证 JWT, 返回用户信息
这就是我们之前创建 Client 的时候所提到的 Implicit flow。
但是这种流程中,Access_token 会出现在浏览器返回的 URL 片段(fragment)里(#access_token=...)
这在很多情况下是极其不安全的,因为相当于把 token 放进了历史数据中,也被某些代理/网关/监控产品完整记录。
而我们开启 Standard flow:ON 之后,就会新增一道验证机制:Code Flow。此时在登录完成之后, Keycloak 返回的 URL 中不再包含 access_token,而是一个临时的 code:
https://focus.ordinis.dev/callback?code=ABC123
然后 前端SPA 用这个 授权响应(code),再去 Keycloak 的 token endpoint 换取 access_token
同时,这个过程中加上了PKCE 验证机制:使用 code 换取 access_tokenn 的主体,必须要是 申请 access_token 的那一个浏览器实例。因此攻击者就算拿到了 code,也没办法用 这个 code 去兑换 access_token。这是因为在 PKCE 机制中, 浏览器实例 申请 code 时,会在本地先 生成一个 code_verifier,这个东西从未出现在网络请求中,攻击者无法获得。
注意:Code Flow 并不能解决 XSS/恶意插件 的攻击类型。这是因为 加上 code flow 之后,SPA 最后还是会用 code 去换取 token ,然后把 token 保存在本地。想要彻底解决 token 暴露在前端的问题,只能采用纯后端 架构(BFF)
Code Flow 能防止的攻击是:Authorization response interception / code injection(授权响应劫持/注入)。
就是指能获取 URL 历史记录的恶意应用/扩展。
对应的 Client 配置
Client authentication = OFF(Public)Standard flow = ON(Authorization Code)- PKCE = S256(必须)
- Valid redirect URIs 包含:
https://focus.ordinis.dev/callback - Web origins 包含:
https://focus.ordinis.dev(或+)
完整流程如下

-
返回该页面的 基本 JSS,HTML,CSS
-
浏览器本地生成
code_verifier,code_challenge,state、nonce -
浏览器重定向到 Keycloak 的 Authorization Endpoint
https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/auth并带查询参数(示例):
client_id=ordinis-focus-sparedirect_uri=https://Ffocus.ordinis.dev/Fcallbackresponse_type=codescope=openid%20profile%20emailstate=RANDOM_STATEnonce=RANDOM_NONCEcode_challenge=...code_challenge_method=S256
-
Keycloak 检查 redirect_uri 是否允许、是否启用标准流等
-
Keycloak 返回登录页资源(HTML/CSS/JS)
-
浏览器把 用户名密码 表单/凭据提交给 Keycloak
-
Keycloak 验证用户身份(密码、OTP、社交登录等)
- 然后准备把用户送回你的
redirect_uri - 此时Keycloak 内部有了用户会话(SSO),浏览器端还没有 token
- 然后准备把用户送回你的
-
Keycloak 返回 302,并附带
code让浏览器跳转到:GET https://focus.ordinis.dev/callback? code=AUTH_CODE & state=RANDOM_STATE在此过程中,浏览器SPA 在
/callback页面解析 URL:取出code,取出state并校验是否等于本地保存的state,仍然没有 token -
SPA 在浏览器发起 HTTP 请求到Keycloak token endpoint 换取 token
POST https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/token使用参数:
grant_type=authorization_codeclient_id=ordinis-focus-spacode=AUTH_CODEredirect_uri=https://focus.ordinis.dev/callbackcode_verifier=THE_VERIFIER_FROM_STEP1
-
Keycloak 校验
code是否有效、是否未使用、是否属于该 client,并用 保存的code_challenge与你现在发来的code_verifier做 PKCE 校验。校验通过后,以 json 形式 签发 token
access_token(JWT,给后端 API 用)id_token(JWT,给 SPA 确认用户身份/展示用)refresh_token(可选;Keycloak 对 SPA 是否给 refresh token 取决于配置与安全策略)expires_in等
-
SPA 用
access_token(通常内存;或 sessionStorage;不建议 localStorage)调用你的后端 APIHTTP Header:
Authorization: Bearer <access_token> -
后端只做离线验证(不需要请求 Keycloak):
- 校验 JWT 签名(用 Keycloak 的 JWKS 公钥)
- 校验
iss(issuer)=https://auth.ordinis.dev/realms/Ordinis - 校验
aud(受众)是否符合你的 API 期望 - 校验
exp未过期 - 从 token 取
sub(Keycloak userId)
然后返回前端渲染所需要的业务信息
一句话概括:
- 浏览器首先访问后端获取基本页面,用户点击“登录”按钮后,前端生成一些验证数据,并组合成一个 URL ,浏览器用这个 URL 去访问 Keycloak。
- 用户在 Keycloak 登录成功后,Keycloak 记录一个SSO session,处理之前 前端生成的一些验证数据,并生成一个
code和state,组合成一个URL发给前端。- 前端收到
code和state,校验state通过后,用code和其他的一些验证信息去请求 Keycloak,获取token- Keycloak 验证
code和其他信息后返回token,前端再用token去调取 后端数据库的更多信息
简化的流程
[ Browser ] <--OIDC--> [ Keycloak ]
|
| access_token
v
[ Backend API ] (只验 JWT)
- Client:
ordinis-focus-web
此结构下,后端不和 Keycloak 交互,除非如果你后端想做 token introspection 或者 主动拉取用户资料(UserInfo endpoint),那才会后端→Keycloak。
思考:后端需要的
user_id是否在 access token 里?是的,
user_id在access_token,access_token是一个 JWT,其sub(subject) 字段就是 Keycloak 内部的 user id。典型 payload(简化):{ "iss": "https://auth.ordinis.dev/realms/Ordinis", "sub": "c1a9c7b0-8b6f-4e7b-9e51-xxxxxx", "aud": "ordinis-focus-spa", "exp": 1710000000, "iat": 1709996400, "scope": "openid profile email" }
思考:后端既然不和 Keycloak 交互,如何确认 token 的安全?
后端验证 access_token 的完整逻辑是:
Keycloak 用 私钥 给 JWT 签名,后端用 Keycloak 的公钥 验证签名
公钥来源:
https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/certs此外还会验证
iss === https://auth.ordinis.dev/realms/Ordinisaudience- 过期时间
思考:第 9 步为什么一定要跳转?不能不跳转吗
表面看你的想法是对的:“反正现在还没有 token,也没业务数据,跳转好像没意义”
第 9 步的跳转:Keycloak → redirect_uri(你的域),其意义不是渲染页面,而是:把控制权从 Keycloak 的安全域,交还给你的应用域。
在这之前:浏览器受 Keycloak 控制,Cookie / session 属于 Keycloak
在这之后:浏览器回到
focus.ordinis.dev,你的 SPA 才能继续流程同时,state 校验必须发生在“你的域”:
其次,OAuth2 协议规定:code 只能通过 redirect_uri 交付
- authorization code 只能:通过
redirect_uri的 query 参数返回- 不能通过:XHR,postMessage,iframe response
这是为了防止:第三方脚本窃取 code,CSRF / clickjacking
架构 B:BFF(后端代理登录)模式
这是最安全的框架,不用担心 token 泄露
简化的流程框架如下:
[ Browser ]
|
| session cookie
v
[ Backend ] <--OIDC--> [ Keycloak ]
- Client:
ordinis-focus-backend - 类型:Confidential,后端用
code + client_secret去 token endpoint 换 token - Keycloak 登录后回到你的站点(callback)
配置
- Standard flow:ON(必须)
- Client authentication:ON(必须)
- Valid redirect URIs:
https://focus.ordinis.dev/auth/callback - Web origins:通常对 BFF 不敏感(因为不是浏览器直接调 token),但如果你的前端会直接调 Keycloak 其它端点再说
- Direct access grants:OFF(建议,避免 password grant)
- Service accounts:通常 OFF(你这是用户登录,不是机器对机器)
- (可选但推荐)启用 PKCE:Keycloak 支持对 confidential client 也要求 PKCE(看版本/策略),启用后更抗“授权响应被截获”的一类问题。
详细流程如下:
- 浏览器(Browser):用户的 UA,只和你的后端交互;不直接拿 Keycloak token
- 后端(Backend / BFF):
https://focus.ordinis.dev- Keycloak:
https://auth.ordinis.dev- 回调地址:
https://focus.ordinis.dev/auth/callback(示例)- 后端会话:
focus_session(HttpOnly + Secure + SameSite)

-
浏览器访问后端:
GET https://focus.ordinis.dev/,获得 基本页面资源 -
浏览器访问后端
GET https://focus.ordinis.dev/auth/login,触发登录:- 后端生成并保存“临时登录状态”(非常关键):
state(防 CSRF/回调注入)、nonce(绑定 id_token 防重放) - 后端 保存这些 临时数据,并关联一个临时 cookie 或临时 session id:例如:
login_tx=abc123(HttpOnly cookie),用来在回调时查到 state/nonce/verifier
- 后端生成并保存“临时登录状态”(非常关键):
-
后端返回 302 Redirect 到 Keycloak Authorization Endpoint:
-
302 Location(示例):浏览器拿到 302,然后自动跳转去 Keycloak。
https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/auth
?client_id=ordinis-focus-backend
&redirect_uri=https%3A%2F%2Ffocus.ordinis.dev%2Fauth%2Fcallback
&response_type=code
&scope=openid%20profile%20email
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
(可选) &code_challenge=...
(可选) &code_challenge_method=S256
-
注意:在 BFF 模式下,这一步仍然是“浏览器跳 Keycloak”。区别是:后续 code 换 token 是后端做,不是浏览器做。
-
Keycloak 收到 browser 的访问请求,校验
client_id是否存在,redirect_uri是否在该 client 的允许列表里,通过后 返回登录页资源(HTML/CSS/JS) -
用户提交用户名密码/OTP:浏览器
POST到 Keycloak 的登录 action(细节由 Keycloak 管),Keycloak 认证成功后建立 Keycloak SSO Session(Keycloak 侧 cookie) -
Keycloak 返回 302 跳转请求到 后端(
redirect_uri),把 code 发回你的后端 callbackhttps://focus.ordinis.dev/auth/callback?code=AUTH_CODE&state=RANDOM_STATE -
浏览器跟随跳转
GET https://focus.ordinis.dev/auth/callback?code=...&state=...,向后端递交 code 和 state -
后端处理 callback:
- 校验 state:从请求里拿
state,用login_tx取出当初生成的expected_state,是否匹配 - 用 code 换 token:后端调用 Token Endpoint
-
请求:
POST https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded -
body(confidential client 标准):
grant_type=authorization_codeclient_id=ordinis-focus-backendclient_secret=...← 关键:只在后端code=AUTH_CODEredirect_uri=https://focus.ordinis.dev/auth/callback- (如果你启用 PKCE)
code_verifier=...
- 校验 state:从请求里拿
-
Keycloak 收到请求,校验
code是否存在、未过期、未使用、是否签发给该client_id
-
redirect_uri是否与签发时一致 -
client_secret是否正确 -
(若启用 PKCE)
code_verifier是否匹配当初的code_challenge
-
Keycloak 返回 JSON 给后端:
access_token(JWT,给后端调用资源/或给后端做授权判断)
id_token(JWT,描述登录用户身份)refresh_token(如果允许离线/刷新)expires_in等
-
后端从
id_token或access_token里取sub(Keycloak userId)email/preferred_username等,然后用sub查业务数据库,获取进一步信息。 -
后端生成
focus_session(随机 session id 或你自签 JWT),并设置 cookie:Set-Cookie: focus_session=...; HttpOnly; Secure; SameSite=Lax(or Strict); Path=/- 302 到应用首页:
Location: /,或者返回一个“登录完成页”HTML
-
浏览器用 cookie 调后端;后端用“自己的会话”鉴权
- 请求:
GET https://focus.ordinis.dev/api/... - 自动带 cookie:
focus_session=... - 后端验证 session:
- 查 session store / 验证你自签 JWT
- 拿到用户 id / sub / 权限
- 返回业务数据
一句话概括:
- 浏览器首先访问后端获取基本页面,用户点击“登录”按钮后,后端生成一些验证数据,并组合成一个 URL 发给前端,让前端用这个 URL 去访问 Keycloak。
- 用户在 Keycloak 登录成功后,Keycloak 记录一个SSO session,处理之前 后端生成的一些验证数据,并生成一个
code和state,组合成一个URL发给前端。前端用这个 URL 去访问后端- 后端收到
code和state,校验state通过后,用code和client_secret去请求 Keycloak,获取token- Keycloak 验证
code后返回token,token中包含了后端需要的一切信息
在这种架构下,攻击者最多只能:在用户会话存续期间,借用户 cookie 发请求做操作(CSRF/XSS 结合)
按照当前我们对 ordinis-focus-web 这个 Client 的 设置,实际上采用的就是 架构B,而不是 架构 A。
架构 C:前端 + 后端都和 Keycloak 交互
[ Browser ] <--OIDC--> [ Keycloak ]
|
| access_token
v
[ Backend API ] <--client_credentials / UMA--> [ Keycloak ]
Keycloak Client 数量:2 个
| Client | 角色 |
|---|---|
ordinis-focus-web | 用户登录 |
ordinis-focus-api | 服务身份 / 授权 |
几个容易混淆的概念:
code:中间凭证(一次性)
功能: 证明Keycloak 已经成功给“某个 Client”完成了一次用户登录
特点:只能用 一次,有极短有效期,不能直接访问 API,不能包含用户权限,必须再去 Keycloak 换 token
当你用
code去 Keycloak 的 token endpoint 兑换时,Keycloak 会返回一个 JSON:{ "access_token": "...", "id_token": "...", "refresh_token": "...", "expires_in": 300 }
access_token:给“资源服务器”(你的后端 API)用的
本质:JWT
用途: HTTP 的请求头
Authorization: Bearer access_token后端关心:签名,iss,aud,sub 👉 你前面设计的“后端只认 JWT,不认 Keycloak”完全就是围绕它。
id_token:给“Client 自己”用的
表示:“这个用户是谁,他刚刚完成了一次登录”
包含:sub,email,name,auth_time
client_secret:用来证明“来换 token 的这个东西,真的是那个 Client”,不参与用户认证
- 只有在“Client 在后端”的情况下,client_secret 才存在
(五)部署 Flask 最小服务(OIDC 登录)
在服务器上:
pip install Flask Authlib requests
创建 app.py:
import os
import secrets
from functools import wraps
from flask import Flask, redirect, url_for, session, request, jsonify
from authlib.integrations.flask_client import OAuth
# === 环境变量 ===
# Keycloak 的基地址(通常是 Keycloak 的外网入口域名)
KEYCLOAK_BASE = os.environ.get("KEYCLOAK_BASE", "https://auth.ordinis.dev")
# Realm 名称(Keycloak 区分不同租户/应用空间的最核心概念)
REALM = os.environ.get("KEYCLOAK_REALM", "Ordinis") # 注意大小写
# OIDC Client ID:你在 Keycloak 里创建的 Client 的 "Client ID"
CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "ordinis-focus-web")
# Client Secret:Keycloak Client 的 secret
CLIENT_SECRET = "mAbFlGgIKg85xnB1dNnv5r9UdKTDNrqX"
# 应用对外的基地址(用于生成 redirect_uri)
APP_BASE_URL = os.environ.get("APP_BASE_URL", "https://focus.ordinis.dev")
# ======= Flask App 初始化 ================
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "this is secret key")
# Flask 用于签名 session cookie 的密钥(非常关键)
# session 内容本质上存在客户端 cookie 里(Flask 默认是 signed cookie),
# ======= Session Cookie 安全策略 =======
# 开发期为了 localhost 回跳更稳:Lax 足够;HTTPS 上线再改 secure=True
app.config.update(
SESSION_COOKIE_HTTPONLY=True, # JS 无法读取 cookie(防止 XSS 直接偷 session)
SESSION_COOKIE_SAMESITE="Lax", # Lax: 允许“顶层导航 GET 跳转”携带 cookie
# OIDC 的授权回跳一般是浏览器顶层跳转,因此 Lax 通常可用
SESSION_COOKIE_SECURE=True, # 仅允许 HTTPS 传输 cookie
)
# ======== Authlib 初始化 + 注册 OIDC Client ======
# OAuth(app) 会把 OAuth Client 能力绑定到 Flask app 上,
# 并使用 Flask session 来暂存授权流程需要的数据(如 state/nonce 等)
oauth = OAuth(app)
oidc = oauth.register( # register(...) 注册一个远端 OIDC Provider(Keycloak):
name="keycloak", # 给这个 provider 起个名字(内部引用用)
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
# OIDC discovery:Keycloak 标准地址,打开它通常能看到 issuer、端点、jwks_uri 等关键信息
server_metadata_url=f"{KEYCLOAK_BASE}/realms/{REALM}/.well-known/openid-configuration",
# openid 必须有(否则不是 OIDC,只是 OAuth2)
client_kwargs={"scope": "openid profile email"},
# profile/email 是常见的用户基础信息 scope
)
# ========= “会话鉴权”中间件 =======
# 它是一个装饰器工厂:输入一个 view 函数 f,输出一个包装后的 wrapper
# wrapper 每次请求都会先检查 session 里有没有 "user"
# 没有:认为未登录 → 重定向到 /login
# 有:放行 → 执行原函数 f
def login_required(f):
@wraps(f) # 不加 @wraps,所有被装饰的函数名字都会变成 wrapper
def wrapper(*args, **kwargs):
if "user" not in session:
return redirect(url_for("login", next=request.path))
# url_for("login", next=request.path) 会把你原来想访问的路径带过去
# 例如访问 /me 未登录,会跳到:/login?next=/me
# 登录完成后再跳回 /me(你在 /login 里把这个 next 存到 session 了)
return f(*args, **kwargs)
return wrapper
# 你现在的鉴权不是“验证 token”,而是“验证 session 是否含 user”。
# 这在 BFF 模式非常典型:token 在后端,浏览器只持有会话 cookie。
# =========== 定义 API ==========
# 首页:展示状态 + 提供入口
@app.get("/")
def index():
# 如果session 中用户未登录
if "user" not in session:
return "<h1>Focus</h1><p>Not logged in</p><a href='/login'>Login</a>"
# 如果session 中用户已登录
u = session["user"]
return (
"<h1>Focus</h1>"
f"<p>Logged in as {u.get('email') or u.get('preferred_username') or u.get('sub')}</p>"
"<a href='/me'>/me</a> | <a href='/logout'>Logout</a>"
)
# /login:启动 OIDC Authorization Code Flow(带 nonce)
@app.get("/login")
def login():
redirect_uri = f"{APP_BASE_URL}/callback" # 这是 Keycloak 登录完成后回跳到你 BFF 的地址
# session["post_login_redirect"] 存储“登录后要去哪里”
session["post_login_redirect"] = request.args.get("next") or "/"
# 风险点(生产必须处理):next 不能完全信任(见后面“开放重定向”问题)
# 1) 生成并保存 nonce(OIDC 必需)
# nonce 用于绑定“这次授权请求”和“回来的 id_token”,防止攻击者把别人的 id_token 重放给你
nonce = secrets.token_urlsafe(16)
session["oidc_nonce"] = nonce
# 2) 发起授权请求时带上 nonce
return oidc.authorize_redirect(redirect_uri, nonce=nonce)
# 1. 从 discovery 文档里拿到 authorization_endpoint
# 2. 生成并保存 state(CSRF 防护)到 session
# 3. 组装授权 URL(含 client_id, redirect_uri, scope, state, nonce 等)
# 4. 返回一个 302,让浏览器跳转到 Keycloak
# /callback:用授权码换 token,并建立本地 session
@app.get("/callback")
def callback():
token = oidc.authorize_access_token()
# 这行代码从请求参数中取出 code 与 state,验证 state 后,向 keycloak 获取 token
# 3) 回调时取出 nonce 做校验与解析
nonce = session.pop("oidc_nonce", None) # pop:读取后删除,因为 nonce 是一次性的
userinfo = oidc.parse_id_token(token, nonce=nonce) # 从 token 中获取用户信息
# 用 Keycloak 的 jwks_uri 下载公钥(或缓存)验证 id_token 签名,解析出 claims,作为 userinfo
session["user"] = dict(userinfo) # 把 userinfo 写进 session
# 从此开始,你的“登录态”不依赖前端 token,而依赖服务端 session:
session["access_token"] = token.get("access_token") # 保存 access_token
# 这是为了后端将来调用 Keycloak Admin API 和 你的资源服务器 API
return redirect(session.pop("post_login_redirect", "/")) # 登录完成后跳转回用户原来要访问的路径
# 同样用 pop 读完删除,避免污染下一次登录流程
# /me:受保护资源示例
@app.get("/me")
@login_required # 挡住未登录请求
def me(): # 已登录则返回 session 内的用户信息(claims)
return jsonify(session["user"])
# /logout:最小登出(只清本地会话)
@app.get("/logout")
def logout():
session.clear() # /logout:最小登出(只清本地会话)
# 最小版:只清本地 session,但不会:清除 Keycloak 那边的 SSO 会话
return redirect(url_for("index"))
# 所以用户点 Logout 后,如果再次点 Login,可能会“秒进”登录成功(因为 Keycloak 仍然记得他登录过)。
# 要做真正 SSO logout,需要:
# 跳转到 Keycloak 的 end_session_endpoint(并带 id_token_hint / post_logout_redirect_uri)
# 或者使用 front-channel/back-channel logout 机制(Keycloak 支持)
# /healthz:健康检查端点
# 给 Nginx / k8s / 监控探针用
# 不应依赖登录态,也不应做外部网络调用(否则容易误报)
@app.get("/healthz")
def healthz():
return "ok", 200
if __name__ == "__main__":
app.run("0.0.0.0", 5000, debug=True)
设置环境变量(把 secret 换成你自己的):
export OIDC_CLIENT_ID="ordinis-focus-web"
export OIDC_CLIENT_SECRET="你从Keycloak复制的client secret"
export KEYCLOAK_REALM="ordinis"
export KEYCLOAK_BASE="https://auth.ordinis.dev"
export APP_BASE_URL="https://focus.ordinis.dev"
export FLASK_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
启动:
python app.py
到这一步,Flask 在 127.0.0.1:5000 工作正常即 可。
(五)Nginx
在 Nginx 新增一个 server(示例),核心是把外部请求转到 127.0.0.1:5000,并带上正确的转发头(Keycloak 的 --proxy-headers=xforwarded 依赖这些头)。(Keycloak)
创建站点:/etc/nginx/sites-available/focus.ordinis.dev
# 可选:HTTP 80 直接跳转到 HTTPS
server {
listen 80;
server_name focus.ordinis.dev;
return 301 https://$host$request_uri;
}
# HTTPS:Cloudflare -> Nginx(Origin Cert) -> Flask
server {
listen 443 ssl http2;
server_name focus.ordinis.dev;
ssl_certificate /etc/nginx/ssl/ordinis.dev/origin-cert.pem;
ssl_certificate_key /etc/nginx/ssl/ordinis.dev/origin-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 关键:反代给 Flask
location / {
proxy_pass http://127.0.0.1:5000;
# 让 Flask 知道外部是 https + 正确 host
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 避免某些重定向/大 header 问题
proxy_buffering off;
proxy_http_version 1.1;
proxy_read_timeout 60s;
}
}
重载:
sudo ln -s /etc/nginx/sites-available/focus.ordinis.dev /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
然后去 cloudflare 里加上DNS:
在 Cloudflare → ordinis.dev → DNS 里新增一条:
- Type:
CNAME - Name:
focus - Target:
ordinis.dev(或你的源站公网 IP 也行,用 A 记录) - Proxy status: Proxied(橙云,建议)
- TTL: Auto
然后再为 auth.ordinis.dev 也做同样的(如果你 Keycloak 是 auth.ordinis.dev)。
如果你已经有
ordinis.dev指向源站,给focus做 CNAME 到ordinis.dev是最省事的做法。